spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { ExternalLink, AlertTriangle } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge, ClaimBadge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { SourceBadge } from '@/components/ui/source-badge';10import { EvidenceTable } from '@/components/data/evidence-table';11import { Pager } from '@/components/ui/pager';12import { getPublicationByPmid, publicationEntities } from '@/lib/queries/publications';13import { evidenceForPublication, evidenceForPublicationCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';14import { loadProvenance } from '@/lib/queries/provenance';15import { fmtDate, fmtInt, humanize, truncate } from '@/lib/format';16import { pageInfo } from '@/lib/pagination';17import { int, type SP } from '@/lib/search-params';1819export const revalidate = 3600;2021export async function generateMetadata({ params }: { params: Promise<{ pmid: string }> }): Promise<Metadata> {22 const p = await getPublicationByPmid((await params).pmid);23 return p ? { title: `${truncate(p.title, 80)} — PMID ${p.pmid}`, description: truncate(p.abstract ?? p.title, 160), robots: p.retracted ? { index: false } : undefined, alternates: { canonical: `/publication/${p.pmid}` } } : { title: 'Publication' };24}2526export default async function PublicationPage({ params, searchParams }: { params: Promise<{ pmid: string }>; searchParams: Promise<SP> }) {27 const { pmid } = await params;28 if (!/^\d{1,10}$/.test(pmid)) notFound();29 const p = await getPublicationByPmid(pmid);30 if (!p) notFound();31 const sp = await searchParams;32 const evTotal = await evidenceForPublicationCount(pmid);33 const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);34 const [edges, evidence] = await Promise.all([publicationEntities(p.id), evTotal ? evidenceForPublication(pmid, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([])]);35 const prov = await loadProvenance(evidence.map((e) => e.provenance_id));36 const validated = edges.filter((e) => e.status === 'validated');37 const candidates = edges.filter((e) => e.status === 'candidate');3839 return (40 <article>41 {p.retracted ? (42 <div role="alert" className="mt-4 flex items-start gap-2 border border-danger bg-danger-soft px-4 py-3 text-[13.5px] text-danger">43 <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />44 <div>45 <p className="font-medium">This publication has been retracted.</p>46 {p.retraction_notice ? <p className="mt-0.5">{p.retraction_notice}</p> : null}47 <p className="mt-0.5">Evidence relying on it is flagged; treat all findings below as withdrawn.</p>48 </div>49 </div>50 ) : null}51 <PageHeader kicker={`Publication${p.is_preprint ? ' · preprint' : ''}`} title={p.title}>52 <p className="mt-2 text-[13.5px] text-ink-2">53 {p.authors.map((a) => a.name).join(', ') || 'Authors not recorded'}54 </p>55 <div className="mt-1 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">56 {p.journal ? <span className="italic">{p.journal}</span> : null}57 {p.pub_date ? <span>{fmtDate(p.pub_date)}</span> : p.pub_year ? <span>{p.pub_year}</span> : null}58 <span className="ci-mono">PMID {p.pmid}</span>59 {p.doi ? (60 <a className="ci-link ci-mono inline-flex items-center gap-1" href={`https://doi.org/${p.doi}`} target="_blank" rel="noopener noreferrer">61 doi:{p.doi} <ExternalLink className="h-3 w-3" aria-hidden />62 </a>63 ) : null}64 {p.pmcid ? <span className="ci-mono">{p.pmcid}</span> : null}65 {p.publication_types.map((t) => (66 <Badge key={t} tone="outline">67 {t}68 </Badge>69 ))}70 <SourceBadge p={{ sourceSlug: 'pubmed', sourceName: 'PubMed', retrievedAt: p.updated_at, ingestRunId: p.ingest_run_id }} />71 <ClaimBadge kind="published" />72 </div>73 </PageHeader>7475 <div className="grid gap-8 lg:grid-cols-[1fr_320px]">76 <div className="space-y-8">77 <Section id="abstract" kicker="Abstract" title="Abstract (excerpt)" description="Only the opening of the abstract is shown; abstract text may carry publisher copyright.">78 {p.abstract ? (79 <>80 <p className="max-w-3xl text-[14.5px] leading-relaxed">{truncate(p.abstract, 300)}</p>81 <a className="ci-link mt-2 inline-flex items-center gap-1 text-[13.5px]" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer">82 Read on PubMed <ExternalLink className="h-3 w-3" aria-hidden />83 </a>84 </>85 ) : (86 <EmptyState compact>87 No abstract stored.{' '}88 <a className="ci-link" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer">89 Read on PubMed90 </a>91 </EmptyState>92 )}93 </Section>9495 <Section id="entities" kicker="Linked entities" title={`Linked entities (${fmtInt(edges.length)})`} description="How each link was made (MeSH, dictionary, registry reference, curation…) and whether it has been validated. Candidate links are not counted in entity statistics.">96 {edges.length ? (97 <div className="space-y-4">98 {[99 { label: 'Validated', rows: validated, tone: 'ok' as const },100 { label: 'Candidate', rows: candidates, tone: 'warn' as const },101 ]102 .filter((g) => g.rows.length)103 .map((g) => (104 <div key={g.label}>105 <p className="mb-1 flex items-center gap-2">106 <Badge tone={g.tone}>{g.label}</Badge> <span className="ci-num text-[12px] text-ink-3">{g.rows.length}</span>107 </p>108 <ul className="flex flex-wrap gap-1.5 text-[13.5px]">109 {g.rows.map((e) => (110 <li key={`${e.entity_type}-${e.entity_id}-${e.method}`} className="inline-flex items-center gap-1.5 border border-rule px-2 py-0.5">111 <span className="ci-kicker">{e.entity_type}</span>112 {e.href ? (113 <Link className="ci-link" href={e.href}>114 {e.label ?? e.entity_id}115 </Link>116 ) : (117 <span>{e.label ?? e.entity_id}</span>118 )}119 <span className="ci-mono text-[10.5px] text-ink-3">{e.method}</span>120 {e.confidence != null ? <span className="ci-num text-[10.5px] text-ink-3">{e.confidence.toFixed(2)}</span> : null}121 </li>122 ))}123 </ul>124 </div>125 ))}126 </div>127 ) : (128 <EmptyState compact>No entity link recorded for this publication.</EmptyState>129 )}130 {p.nct_ids.length ? (131 <p className="mt-3 text-[13px]">132 <span className="ci-kicker mr-2">Registered trials</span>133 {p.nct_ids.map((n) => (134 <Link key={n} className="ci-mono ci-link mr-2" href={`/trial/${n}`}>135 {n}136 </Link>137 ))}138 </p>139 ) : null}140 </Section>141142 <Section id="evidence" kicker="Curated evidence" title={`Evidence citing this paper (${fmtInt(evTotal)})`} description={evTotal > EVIDENCE_PAGE_SIZE ? `${EVIDENCE_PAGE_SIZE} items per page.` : undefined}>143 {evidence.length ? (144 <>145 <EvidenceTable146 items={evidence}147 prov={prov}148 showCancer149 summary={150 <>151 Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items152 </>153 }154 />155 <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(pg) => `/publication/${p.pmid}${pg > 1 ? `?evPage=${pg}` : ''}#evidence`} label="Evidence pages" noun="evidence items" />156 </>157 ) : (158 <EmptyState compact>No curated evidence item cites this publication.</EmptyState>159 )}160 </Section>161 </div>162163 <aside className="space-y-8">164 <Section id="record" kicker="Record" title="Metadata" level={3}>165 <KV166 items={[167 { k: 'CancerIndex ID', v: <span className="ci-mono">{p.id}</span> },168 { k: 'Language', v: p.language },169 { k: 'Cited by', v: p.cited_by_count != null ? <span className="ci-num">{fmtInt(p.cited_by_count)}</span> : null },170 { k: 'MeSH major', v: p.mesh_terms.filter((m) => m.major).map((m) => m.descriptor).join('; ') || null },171 { k: 'MeSH other', v: p.mesh_terms.filter((m) => !m.major).slice(0, 15).map((m) => m.descriptor).join('; ') || null },172 ]}173 />174 <Freshness dataUpdatedAt={p.updated_at} sourceUpdatedAt={p.pub_date} extra="source: pubmed" />175 </Section>176 <Note>Bibliographic data from PubMed (NLM). CancerIndex stores abstracts for indexing only and displays a short excerpt with a link to the record.</Note>177 <p className="text-[12px] text-ink-3">Publication type: {p.publication_types.map(humanize).join(', ') || 'not recorded'}.</p>178 </aside>179 </div>180 </article>181 );182}183